package drds.common.db.xml_mapping.binding;


import drds.common.db.xml_mapping.mapping.Mapper;
import drds.common.db.xml_mapping.mapping.SqlType;
import drds.common.db.xml_mapping.reflection.MetaObject;
import drds.common.db.xml_mapping.reflection.ParamNameResolver;
import drds.common.db.xml_mapping.reflection.TypeParameterResolver;
import drds.common.db.xml_mapping.session.Configuration;
import drds.common.db.xml_mapping.session.MapperContext;
import drds.common.db.xml_mapping.session.ResultHandler;
import drds.common.db.xml_mapping.session.RowBounds;

import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;


public class MapperMethod {


  private final SqlCommand sqlCommand;
  private final MethodSignature methodSignature;


  public MapperMethod(Class<?> mapperInterface, Method methodSignature, Configuration configuration) {
    this.sqlCommand = new SqlCommand(configuration, mapperInterface, methodSignature);
    this.methodSignature = new MethodSignature(configuration, mapperInterface, methodSignature);
  }

  public Object execute(MapperContext mapperContext, Object[] args) {
    Object result;
    switch (sqlCommand.getType()) {
      case insert: {
        Object param = methodSignature.convertArgsToSqlCommandParam(args);
        result = rowCountResult(mapperContext.insert(sqlCommand.getName(), param));
        break;
      }
      case update: {
        Object param = methodSignature.convertArgsToSqlCommandParam(args);
        result = rowCountResult(mapperContext.update(sqlCommand.getName(), param));
        break;
      }
      case delete: {
        Object param = methodSignature.convertArgsToSqlCommandParam(args);
        result = rowCountResult(mapperContext.delete(sqlCommand.getName(), param));
        break;
      }
      case select:
        if (methodSignature.returnsVoid() && methodSignature.hasResultHandler()) {
          executeWithResultHandler(mapperContext, args);
          result = null;
        } else if (methodSignature.returnsMany()) {
          result = executeForMany(mapperContext, args);
        } else if (methodSignature.returnsMap()) {
          result = executeForMap(mapperContext, args);
        } else {
          Object param = methodSignature.convertArgsToSqlCommandParam(args);
          result = mapperContext.selectOne(sqlCommand.getName(), param);
          if (methodSignature.returnsOptional()
            && (result == null || !methodSignature.getReturnType().equals(result.getClass()))) {
            result = Optional.ofNullable(result);
          }
        }
        break;
      case flush:
        result = mapperContext.flushStatements();
        break;
      default:
        throw new BindingException("Unknown execution methodSignature for: " + sqlCommand.getName());
    }
    if (result == null && methodSignature.getReturnType().isPrimitive() && !methodSignature.returnsVoid()) {
      throw new BindingException("Mapper methodSignature '" + sqlCommand.getName()
        + " attempted to return null from a methodSignature with a primitive return parameter_and_result_value_type (" + methodSignature.getReturnType() + ").");
    }
    return result;
  }

  private Object rowCountResult(int rowCount) {
    final Object result;
    if (methodSignature.returnsVoid()) {
      result = null;
    } else if (Integer.class.equals(methodSignature.getReturnType()) || Integer.TYPE.equals(methodSignature.getReturnType())) {
      result = rowCount;
    } else if (Long.class.equals(methodSignature.getReturnType()) || Long.TYPE.equals(methodSignature.getReturnType())) {
      result = (long) rowCount;
    } else if (Boolean.class.equals(methodSignature.getReturnType()) || Boolean.TYPE.equals(methodSignature.getReturnType())) {
      result = rowCount > 0;
    } else {
      throw new BindingException("Mapper methodSignature '" + sqlCommand.getName() + "' has an unsupported return parameter_and_result_value_type: " + methodSignature.getReturnType());
    }
    return result;
  }

  private void executeWithResultHandler(MapperContext mapperContext, Object[] args) {
    Mapper MappedStatement = mapperContext.getConfiguration().getMappedStatement(sqlCommand.getName());
    if (void.class.equals(MappedStatement.getResultMaps().get(0).getType())) {
      throw new BindingException("methodSignature " + sqlCommand.getName()
        + " needs either a @ResultMap annotation, a @ResultType annotation,"
        + " or a resultType attribute in XML so a ResultHandler can be used as a parameter.");
    }
    Object param = methodSignature.convertArgsToSqlCommandParam(args);
    if (methodSignature.hasRowBounds()) {
      RowBounds rowBounds = methodSignature.extractRowBounds(args);
      mapperContext.select(sqlCommand.getName(), param, rowBounds, methodSignature.extractResultHandler(args));
    } else {
      mapperContext.select(sqlCommand.getName(), param, methodSignature.extractResultHandler(args));
    }
  }

  private <E> Object executeForMany(MapperContext mapperContext, Object[] args) {
    List<E> result;
    Object param = methodSignature.convertArgsToSqlCommandParam(args);
    if (methodSignature.hasRowBounds()) {
      RowBounds rowBounds = methodSignature.extractRowBounds(args);
      result = mapperContext.selectList(sqlCommand.getName(), param, rowBounds);
    } else {
      result = mapperContext.selectList(sqlCommand.getName(), param);
    }
    // issue #510 Collections & arrays support
    if (!methodSignature.getReturnType().isAssignableFrom(result.getClass())) {
      if (methodSignature.getReturnType().isArray()) {
        return convertToArray(result);
      } else {
        return convertToDeclaredCollection(mapperContext.getConfiguration(), result);
      }
    }
    return result;
  }


  private <E> Object convertToDeclaredCollection(Configuration configuration, List<E> list) {
    Object collection = configuration.getObjectFactory().create(methodSignature.getReturnType());
    MetaObject metaObject = configuration.newMetaObject(collection);
    metaObject.addAll(list);
    return collection;
  }

  @SuppressWarnings("unchecked")
  private <E> Object convertToArray(List<E> list) {
    Class<?> arrayComponentType = methodSignature.getReturnType().getComponentType();
    Object array = Array.newInstance(arrayComponentType, list.size());
    if (arrayComponentType.isPrimitive()) {
      for (int i = 0; i < list.size(); i++) {
        Array.set(array, i, list.get(i));
      }
      return array;
    } else {
      return list.toArray((E[]) array);
    }
  }

  private <K, V> Map<K, V> executeForMap(MapperContext mapperContext, Object[] args) {
    Map<K, V> result;
    Object param = methodSignature.convertArgsToSqlCommandParam(args);
    if (methodSignature.hasRowBounds()) {
      RowBounds rowBounds = methodSignature.extractRowBounds(args);
      result = mapperContext.selectMap(sqlCommand.getName(), param, methodSignature.getMapKey(), rowBounds);
    } else {
      result = mapperContext.selectMap(sqlCommand.getName(), param, methodSignature.getMapKey());
    }
    return result;
  }

  public static class ParamMap<V> extends HashMap<String, V> {

    private static final long serialVersionUID = -2212268410512043556L;

    @Override
    public V get(Object key) {
      if (!super.containsKey(key)) {
        throw new BindingException("Parameter '" + key + "' not found. Available parameters are " + keySet());
      }
      return super.get(key);
    }

  }

  public static class SqlCommand {

    private final String name;
    private final SqlType type;

    public SqlCommand(Configuration configuration, Class<?> mapperInterface, Method method) {
      final String methodName = method.getName();
      final Class<?> declaringClass = method.getDeclaringClass();
      Mapper MappedStatement = resolveMappedStatement(mapperInterface, methodName, declaringClass,
        configuration);
      if (MappedStatement == null) {
        {
          throw new BindingException("Invalid bound statement (not found): "
            + mapperInterface.getName() + "." + methodName);
        }
      } else {
        name = MappedStatement.getId();
        type = MappedStatement.getSqlType();
        if (type == SqlType.unknown) {
          throw new BindingException("Unknown execution methodSignature for: " + name);
        }
      }
    }

    public String getName() {
      return name;
    }

    public SqlType getType() {
      return type;
    }

    private Mapper resolveMappedStatement(Class<?> mapperInterface, String methodName,
                                          Class<?> declaringClass, Configuration configuration) {
      String statementId = mapperInterface.getName() + "." + methodName;
      if (configuration.hasStatement(statementId)) {
        return configuration.getMappedStatement(statementId);
      } else if (mapperInterface.equals(declaringClass)) {
        return null;
      }
      for (Class<?> superInterface : mapperInterface.getInterfaces()) {

        if (declaringClass.isAssignableFrom(superInterface)) {
          Mapper MappedStatement = resolveMappedStatement(superInterface, methodName,
            declaringClass, configuration);
          if (MappedStatement != null) {
            return MappedStatement;
          }
        }
      }
      return null;
    }
  }

  public static class MethodSignature {

    private final boolean returnsMany;
    private final boolean returnsMap;
    private final boolean returnsVoid;

    private final boolean returnsOptional;
    private final Class<?> returnType;
    private final String mapKey;
    private final Integer resultHandlerIndex;
    private final Integer rowBoundsIndex;
    private final ParamNameResolver paramNameResolver;

    public MethodSignature(Configuration configuration, Class<?> mapperInterface, Method method) {
      Type resolvedReturnType = TypeParameterResolver.resolveReturnType(method, mapperInterface);
      if (resolvedReturnType instanceof Class<?>) {
        this.returnType = (Class<?>) resolvedReturnType;
      } else if (resolvedReturnType instanceof ParameterizedType) {
        this.returnType = (Class<?>) ((ParameterizedType) resolvedReturnType).getRawType();
      } else {
        this.returnType = method.getReturnType();
      }
      this.returnsVoid = void.class.equals(this.returnType);
      this.returnsMany = configuration.getObjectFactory().isCollection(this.returnType) || this.returnType.isArray();

      this.returnsOptional = Optional.class.equals(this.returnType);
      this.mapKey = getMapKey(method);
      this.returnsMap = this.mapKey != null;
      this.rowBoundsIndex = getUniqueParamIndex(method, RowBounds.class);
      this.resultHandlerIndex = getUniqueParamIndex(method, ResultHandler.class);
      this.paramNameResolver = new ParamNameResolver(configuration, method);
    }

    public Object convertArgsToSqlCommandParam(Object[] args) {
      return paramNameResolver.getNamedParams(args);
    }

    public boolean hasRowBounds() {
      return rowBoundsIndex != null;
    }

    public RowBounds extractRowBounds(Object[] args) {
      return hasRowBounds() ? (RowBounds) args[rowBoundsIndex] : null;
    }

    public boolean hasResultHandler() {
      return resultHandlerIndex != null;
    }

    public ResultHandler extractResultHandler(Object[] args) {
      return hasResultHandler() ? (ResultHandler) args[resultHandlerIndex] : null;
    }

    public String getMapKey() {
      return mapKey;
    }

    public Class<?> getReturnType() {
      return returnType;
    }

    public boolean returnsMany() {
      return returnsMany;
    }

    public boolean returnsMap() {
      return returnsMap;
    }

    public boolean returnsVoid() {
      return returnsVoid;
    }


    /**
     * return whether return parameter_and_result_value_type is {@code java.util.Optional}.
     *
     * @return return {@code true}, if return parameter_and_result_value_type is {@code java.util.Optional}
     * @since 3.5.0
     */
    public boolean returnsOptional() {
      return returnsOptional;
    }

    private Integer getUniqueParamIndex(Method method, Class<?> paramType) {
      Integer index = null;
      final Class<?>[] argTypes = method.getParameterTypes();
      for (int i = 0; i < argTypes.length; i++) {
        if (paramType.isAssignableFrom(argTypes[i])) {
          if (index == null) {
            index = i;
          } else {
            throw new BindingException(method.getName() + " cannot have multiple " + paramType.getSimpleName() + " parameters");
          }
        }
      }
      return index;
    }

    private String getMapKey(Method method) {
      String mapKey = null;
      return mapKey;
    }
  }

}
